가상 면접 사례로 배우는 생성형 AI 시스템 설계 — 2장 지메일 스마트 편지쓰기
출처: 가상 면접 사례로 배우는 생성형 AI 시스템 설계 (알리 아미니안, 알렉스 쉬) 2026 현실 업데이트: 책의 핵심 개념을 유지하면서, 실무에서 실제로 쓰는 방식으로 재구성했다.
2026년 기준 — 스마트 편지쓰기를 만드는 두 가지 길
책은 트랜스포머를 처음부터 설계·학습하는 시나리오를 가정한다. 그러나 2026년 현실에서 "스마트 편지쓰기"를 만드는 방식은 사실 두 갈래다.
[경로 A] API 활용 (99%의 프로덕션 스타트업)
사용자 입력 → Claude/GPT-4o API (스트리밍) → 제안 표시
구현: 2시간 / 비용: 토큰당 과금 / 장점: 즉시 배포
[경로 B] 모델 직접 파인튜닝 (대형 서비스, 구글 수준)
자체 데이터 → 오픈소스 모델(Llama 3.2, Gemma 3) 파인튜닝 → 서빙
구현: 수주 / 비용: GPU 인프라 / 장점: 커스터마이징, 비용 절감
이 장의 내용은 경로 B의 설계 원리다. 경로 A로 시작해서 트래픽이 쌓이면 경로 B로 전환하는 것이 2026년 실무 패턴이다.
전체 흐름도 (책 원본 → 2026 매핑)
[책의 개념] [2026 실제 도구]
─────────────────────────────────────────────────
요구사항 구체화 그대로 (불변)
ML 문제 정의 (디코더 전용) GPT/Gemma/Llama 계열
데이터 준비 (BPE 토큰화) tiktoken, HF tokenizers
사전 학습 건너뜀 → 사전학습된 모델 다운로드
미세 조정 LoRA / QLoRA (PEFT)
샘플링 (빔 검색) HF generate() API
평가 (Perplexity) evaluate 라이브러리, LangSmith
시스템 설계 (트리거/후처리) FastAPI + vLLM 서빙
선수 지식 체크리스트
- [ ] Python 기초 (리스트, 딕셔너리, 함수)
- [ ]
pip install transformers torch실행 가능 - [ ] API 키 발급 경험 (OpenAI, Anthropic 중 하나)
- [ ] 벡터와 행렬의 개념 (곱셈 결과 차원을 계산할 수 있으면 충분)
핵심 키워드
| 용어 | 2026 실무 의미 |
|---|---|
| 디코더 전용 트랜스포머 | GPT, Claude, Llama, Gemma의 기반 구조. "다음 토큰 예측" 기계 |
| 토큰화 (BPE) | tiktoken.get_encoding("cl100k_base") 한 줄로 사용 |
| 텍스트 임베딩 | 모델 내부 lookup table. 직접 구현할 일 없음 |
| 위치 인코딩 | RoPE(Llama), ALiBi 등으로 진화. 개념만 알면 됨 |
| 사전 학습 | 이미 된 것을 다운로드. Meta/Google/Anthropic이 수십억 달러 투자 |
| 미세 조정 | LoRA로 1~2% 파라미터만 학습. A100 1장으로 가능 |
| 빔 검색 | model.generate(num_beams=4) 한 줄 |
| Perplexity | evaluate.load("perplexity") 라이브러리 제공 |
| vLLM | 프로덕션 LLM 서빙 엔진. PagedAttention으로 처리량 24배 |
2.1 요구사항 구체화
핵심 (변경 없음)
면접에서 가장 먼저 해야 할 것은 범위 확정이다. 이 단계는 2026년에도 그대로다.
| 요구사항 | 값 | 2026 코멘트 |
|---|---|---|
| 응답 시간 | ≤100ms | API 기반이면 200~500ms가 현실. 직접 서빙하면 vLLM으로 달성 가능 |
| 동시 사용자 | 18억 명 | API 기반이면 rate limit 협상 문제 |
| 편향 방지 | 필수 | Llama Guard 2/3 같은 안전 모델을 파이프라인에 추가 |
| 개인화 | 우선 미포함 | 2026에는 RAG + 사용자 히스토리로 쉽게 추가 가능 |
2.2 ML 문제 정의
한 줄 요약
"다음 토큰 예측" — 이것이 LLM의 전부다. 2026년에도 변하지 않은 핵심.
왜 트랜스포머인가 (아직도 유효)
RNN (LSTM, GRU) 트랜스포머
───────────── ──────────────
토큰을 하나씩 처리 모든 토큰 동시 처리 (병렬)
기울기 소실 문제 셀프 어텐션으로 장거리 문맥 유지
2024년 이후 거의 안 씀 GPT/Claude/Gemini 모두 이것
2026년에 더 중요해진 것: 트랜스포머 3종 선택
# 작업에 따라 모델 선택이 달라진다
# 분류/이해 작업 → 인코더 전용 (BERT 계열)
from transformers import BertForSequenceClassification
model = BertForSequenceClassification.from_pretrained("bert-base-uncased")
# 텍스트 생성 (스마트 편지쓰기) → 디코더 전용 ✅
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-3B")
# 번역/요약 → 인코더-디코더 (T5 계열)
from transformers import T5ForConditionalGeneration
model = T5ForConditionalGeneration.from_pretrained("google/flan-t5-base")
2.3 데이터 준비
한 줄 요약
2026년 실무: 정제·토큰화 로직을 직접 짜되, 토큰화 라이브러리는 가져다 쓴다.
2.3.1 텍스트 정제 — 실제 코드
import re
from presidio_analyzer import AnalyzerEngine # pip install presidio-analyzer
# 기밀 정보 마스킹 (Presidio: Microsoft 오픈소스)
analyzer = AnalyzerEngine()
def clean_email(text: str) -> str:
"""이메일 텍스트 정제: 기밀 정보 마스킹 + 불필요 기호 제거"""
# 1) Presidio로 PII 탐지 후 마스킹
results = analyzer.analyze(text=text, language="en")
for result in sorted(results, key=lambda x: x.start, reverse=True):
text = text[:result.start] + "[REDACTED]" + text[result.end:]
# 2) 불필요한 기호 제거 (이모티콘, ©, ™ 등)
text = re.sub(r'[^\x00-\x7F]+', ' ', text) # 비 ASCII
text = re.sub(r'[©™®]', '', text)
# 3) 연속 공백 정규화
text = re.sub(r'\s+', ' ', text).strip()
return text
# 사용 예시
raw = "Hi [email protected], call me at 010-1234-5678 ©2024"
print(clean_email(raw))
# → "Hi [REDACTED], call me at [REDACTED] 2024"
2.3.2 텍스트 토큰화 — 3종 비교 후 BPE 채택
import tiktoken # pip install tiktoken
# GPT-4o가 쓰는 BPE 토크나이저 (cl100k_base)
enc = tiktoken.get_encoding("cl100k_base")
text = "Let's go to NYC"
tokens = enc.encode(text)
print(tokens) # [10267, 596, 733, 311, 38103]
print(enc.decode(tokens)) # "Let's go to NYC"
# 어휘집 크기 확인
print(enc.n_vocab) # 100,277개 (하위 단어 단위)
왜 BPE인가 — 직관적 비교:
text = "unhappily"
# 문자 단위: 어휘집 작지만 의미 없음
char = list("unhappily") # ['u','n','h','a','p','p','i','l','y']
# 단어 단위: 어휘집 너무 큼, 미등록 단어 처리 불가
# word = ["unhappily"] # 300,000+ 어휘집 필요
# BPE (하위 단어): 균형 ✅
bpe = enc.encode("unhappily")
decoded = [enc.decode([t]) for t in bpe]
print(decoded) # ['un', 'happ', 'ily'] 처럼 의미 단위로 분해
| 방식 | 어휘집 크기 | 미등록 단어 | 실무 사용 |
|---|---|---|---|
| 문자 단위 | 100~1,000 | 없음 | 거의 안 씀 |
| 단어 단위 | 300,000+ | 처리 어려움 | 거의 안 씀 |
| BPE (하위 단어) | 50,000~150,000 | 분해 처리 | GPT-4, Claude, Gemma |
2.3.3 HuggingFace Datasets로 데이터 파이프라인
from datasets import load_dataset
from transformers import AutoTokenizer
# 이메일 데이터셋 로드 (Enron 공개 데이터)
dataset = load_dataset("aeslc", split="train") # 이메일 요약 데이터
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-3B")
tokenizer.pad_token = tokenizer.eos_token
def tokenize_fn(examples):
"""이메일 → 다음 토큰 예측 형식으로 변환"""
# 프롬프트 형식: [SUBJECT] {제목} [BODY] {본문}
texts = [
f"[SUBJECT] {s} [BODY] {b}"
for s, b in zip(examples["subject"], examples["body"])
]
return tokenizer(
texts,
truncation=True,
max_length=512,
padding="max_length"
)
tokenized = dataset.map(tokenize_fn, batched=True)
2.4 모델 개발
2.4.1 트랜스포머 구조 — 2026년 시각
책의 4요소(임베딩→위치인코딩→트랜스포머블록→예측헤드)는 여전히 유효하다. 다만 직접 구현할 일은 없고, 어떤 레이어가 무슨 역할인지 이해하는 것이 중요하다.
from transformers import AutoModelForCausalLM
import torch
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.2-3B",
torch_dtype=torch.bfloat16, # 메모리 절약
device_map="auto" # GPU/CPU 자동 배분
)
# 내부 구조 확인
print(model)
# LlamaForCausalLM(
# (model): LlamaModel(
# (embed_tokens): Embedding(128256, 3072) ← 텍스트 임베딩
# (layers): ModuleList(
# (0-27): 28 x LlamaDecoderLayer(
# (self_attn): LlamaSdpaAttention(...) ← 셀프 어텐션
# (mlp): LlamaMLP(...) ← 순방향 신경망
# )
# )
# (norm): LlamaRMSNorm(...)
# )
# (lm_head): Linear(3072, 128256) ← 예측 헤드
# )
위치 인코딩: 2026년 현실
책에서 배운 것 → 실제 최신 모델
─────────────────────────────────────
사인-코사인 인코딩 → RoPE (Llama, Gemma, Qwen)
학습 위치 인코딩 → ALiBi (일부 모델)
RoPE가 2026년 사실상 표준. 상대 위치를 어텐션 계산 안에서 처리해서
더 긴 컨텍스트(128K+)로 자연스럽게 확장 가능.
2.4.2 사전 학습 — 2026년에는 "다운로드"
# 2026년 현실: 처음부터 사전 학습은 구글/Meta/Anthropic만 한다.
# 우리는 HuggingFace에서 다운로드.
from huggingface_hub import snapshot_download
# 실제 사용 예시들
models = {
"초소형 (모바일/엣지)": "google/gemma-3-1b-it", # 1B
"소형 (서버 1대)": "meta-llama/Llama-3.2-3B", # 3B
"중형 (서버 2-4대)": "meta-llama/Llama-3.1-8B", # 8B
"대형 (클러스터)": "meta-llama/Llama-3.1-70B", # 70B
}
# 스마트 편지쓰기: 3B~8B 정도가 품질/속도 균형 최적
model_id = "meta-llama/Llama-3.2-3B-Instruct"
snapshot_download(model_id, local_dir="./models/llama-3.2-3b")
2.4.3 미세 조정 — LoRA로 효율적으로
2026년 파인튜닝의 핵심은 LoRA (Low-Rank Adaptation)다. 전체 파라미터 대신 1~2%의 어댑터만 학습해 A100 1장으로 8B 모델 파인튜닝이 가능하다.
# pip install peft trl transformers accelerate bitsandbytes
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset
import torch
# 1) 4비트 양자화로 모델 로드 (메모리 절약)
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.2-3B",
quantization_config=bnb_config,
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-3B")
tokenizer.pad_token = tokenizer.eos_token
# 2) LoRA 어댑터 설정 (전체 파라미터의 ~1%만 학습)
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16, # 랭크: 작을수록 효율적, 클수록 표현력
lora_alpha=32, # 스케일링 팩터
target_modules=["q_proj", "v_proj"], # 어텐션 레이어에 적용
lora_dropout=0.05,
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 4,194,304 || all params: 3,216,215,040 || trainable%: 0.13%
# 3) 이메일 데이터로 학습
def format_email_prompt(example):
"""이메일 다음 단어 예측 형식"""
return {
"text": f"[SUBJECT] {example['subject']} [BODY] {example['body']}"
}
dataset = load_dataset("aeslc", split="train").map(format_email_prompt)
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
args=SFTConfig(
output_dir="./email-compose-model",
num_train_epochs=3,
per_device_train_batch_size=4,
learning_rate=2e-4,
fp16=True,
logging_steps=50,
),
)
trainer.train()
model.save_pretrained("./email-compose-lora")
LoRA vs 전체 파인튜닝 비교 (8B 모델 기준):
| 전체 파인튜닝 | LoRA (r=16) | QLoRA (4bit+LoRA) | |
|---|---|---|---|
| 필요 VRAM | ~80GB (A100 2장) | ~40GB | ~10GB (A100 1장) |
| 학습 시간 | 긺 | 중간 | 길음 (양자화 오버헤드) |
| 성능 | 최고 | 거의 동일 | 약간 낮음 |
| 2026 추천 | ❌ 비용 과다 | ✅ 균형 | ✅ GPU 제약 시 |
2.4.4 샘플링 — 빔 검색 실제 구현
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_path = "./email-compose-lora" # 파인튜닝된 모델
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-3B")
model = AutoModelForCausalLM.from_pretrained(model_path, device_map="auto")
def smart_compose(email_context: str, max_new_tokens: int = 10) -> list[str]:
"""
스마트 편지쓰기: 이메일 문맥에서 다음 구절 제안
Args:
email_context: 현재까지 작성된 이메일 내용
max_new_tokens: 최대 제안 단어 수
Returns:
상위 k개 완성 구절 리스트
"""
prompt = f"[BODY] {email_context}"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
# 빔 검색 (결정적 샘플링) ← 책의 핵심 개념
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
num_beams=5, # 빔 너비 k=5
num_return_sequences=3, # 상위 3개 후보 반환
early_stopping=True,
no_repeat_ngram_size=2, # 반복 방지
)
# 입력 부분 제거 후 새로 생성된 텍스트만 추출
input_len = inputs["input_ids"].shape[1]
suggestions = [
tokenizer.decode(out[input_len:], skip_special_tokens=True).strip()
for out in outputs
]
# 후처리: 빈 제안, 너무 긴 제안 필터링
suggestions = [s for s in suggestions if 1 <= len(s.split()) <= 8]
return suggestions
# 사용 예시
context = "Hi Ethan! Hope you are"
suggestions = smart_compose(context)
for i, s in enumerate(suggestions, 1):
print(f"제안 {i}: {context} [TAB] {s}")
# 제안 1: Hi Ethan! Hope you are [TAB] doing well.
# 제안 2: Hi Ethan! Hope you are [TAB] having a great week.
# 제안 3: Hi Ethan! Hope you are [TAB] well.
2.4.5 [심화] 스트리밍 응답 — 100ms 요구사항 달성
from transformers import TextIteratorStreamer
from threading import Thread
def smart_compose_stream(email_context: str):
"""스트리밍으로 토큰을 하나씩 반환해 체감 응답 속도 개선"""
streamer = TextIteratorStreamer(tokenizer, skip_special_tokens=True)
inputs = tokenizer(f"[BODY] {email_context}", return_tensors="pt").to(model.device)
# 백그라운드 스레드에서 생성
thread = Thread(target=model.generate, kwargs={
**inputs,
"max_new_tokens": 10,
"streamer": streamer,
})
thread.start()
# 토큰이 생성될 때마다 즉시 반환
for token in streamer:
yield token # FastAPI StreamingResponse에 연결
# FastAPI 연동
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/compose")
async def compose(context: str):
return StreamingResponse(
smart_compose_stream(context),
media_type="text/plain"
)
2.5 평가
2.5.1 오프라인 평가 — 라이브러리 활용
import evaluate # pip install evaluate
# Perplexity 계산
perplexity = evaluate.load("perplexity", module_type="metric")
test_texts = [
"I hope you are doing well.",
"Please let me know if you have any questions.",
"Looking forward to hearing from you.",
]
results = perplexity.compute(
predictions=test_texts,
model_id="./email-compose-lora"
)
print(f"평균 Perplexity: {results['mean_perplexity']:.2f}")
# 낮을수록 좋음. 일반 텍스트: ~50, 도메인 특화: ~20 목표
# ExactMatch@N 직접 구현
def exact_match_at_n(predictions: list[str], references: list[str], n: int) -> float:
"""
생성된 N개 단어가 정답의 첫 N개와 정확히 일치하는 비율
Args:
predictions: 모델이 생성한 텍스트 리스트
references: 실제 정답 텍스트 리스트
n: 비교할 단어 수
"""
matches = 0
for pred, ref in zip(predictions, references):
pred_words = pred.lower().split()[:n]
ref_words = ref.lower().split()[:n]
if pred_words == ref_words:
matches += 1
return matches / len(predictions)
# 예시
preds = ["are doing well today", "meeting you today", "are doing well"]
refs = ["are doing well today", "seeing you today", "are doing well"]
for n in [1, 2, 3, 4]:
score = exact_match_at_n(preds, refs, n)
print(f"ExactMatch@{n}: {score:.2f}")
# ExactMatch@1: 0.67
# ExactMatch@2: 0.67
# ExactMatch@3: 0.67
# ExactMatch@4: 0.33
2.5.2 온라인 평가 — 2026년 실무 도구
# LangSmith로 프로덕션 모니터링 (2026년 표준 도구)
# pip install langsmith
from langsmith import Client
from langsmith.wrappers import wrap_openai
client = Client()
# A/B 테스트: 빔 너비 k=3 vs k=5
@client.traceable(run_type="chain", name="smart_compose_v1")
def compose_v1(context: str) -> str:
return smart_compose(context, num_beams=3)[0]
@client.traceable(run_type="chain", name="smart_compose_v2")
def compose_v2(context: str) -> str:
return smart_compose(context, num_beams=5)[0]
| 지표 | 측정 방법 | 목표 기준 |
|---|---|---|
| Perplexity | evaluate 라이브러리 | < 20 (이메일 도메인) |
| ExactMatch@3 | 직접 구현 | > 0.4 |
| 통과율(수락률) | 사용자 로그 분석 | > 25% (Gmail 실제 수치) |
| p50 응답 시간 | vLLM 메트릭 | < 100ms |
| p99 응답 시간 | vLLM 메트릭 | < 300ms |
2.6 전체 시스템 설계
2026년 프로덕션 아키텍처
사용자 입력 (React/웹)
│ WebSocket 스트리밍
▼
[API Gateway / Nginx]
│
▼
[트리거링 서비스 — FastAPI]
단어 수 ≥ 2? + 디바운스 300ms?
│ Yes → 활성화
▼
[구절 생성기 — FastAPI]
vLLM 서버 호출 (빔 검색)
│
├→ [vLLM 서빙 엔진]
│ Llama-3.2-3B-LoRA
│ PagedAttention으로 처리량 최적화
│
├─ 긴 제안 필터 (> 8단어 제거)
├─ 신뢰도 필터 (score < 0.1 제거)
│
▼
[후처리 서비스]
Llama Guard 3 (안전 필터)
성 중립 대체 규칙
│
▼
사용자에게 제안 스트리밍
트리거링 서비스 구현
from fastapi import FastAPI
import asyncio
app = FastAPI()
class TriggerService:
"""스마트 편지쓰기 활성화 시점 결정"""
MIN_WORDS = 2 # 최소 단어 수
DEBOUNCE_MS = 300 # 연속 입력 중 대기 시간
def should_trigger(self, text: str) -> bool:
words = text.strip().split()
# 너무 짧으면 문맥 부족
if len(words) < self.MIN_WORDS:
return False
# 문장 끝(마침표, 줄바꿈)이면 새 문장 시작 대기
if text.rstrip().endswith(('.', '!', '?', '\n')):
return False
return True
trigger = TriggerService()
@app.post("/trigger")
async def check_trigger(text: str):
return {"should_trigger": trigger.should_trigger(text)}
vLLM 서빙 — 프로덕션 배포
# vLLM 설치 및 서버 시작 (실제 명령어)
pip install vllm
# 파인튜닝된 LoRA 어댑터와 함께 서빙
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.2-3B \
--enable-lora \
--lora-modules email-compose=./email-compose-lora \
--max-model-len 512 \
--gpu-memory-utilization 0.9 \
--port 8000
# vLLM OpenAI 호환 API 호출
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
response = client.completions.create(
model="email-compose", # LoRA 어댑터 이름
prompt="[BODY] Hi Ethan, I hope you are",
max_tokens=10,
n=3, # 빔 검색 결과 3개
stream=True # 스트리밍
)
for chunk in response:
print(chunk.choices[0].text, end="", flush=True)
후처리 서비스 — 편향 제거
import re
class PostProcessor:
"""편향 탐지 및 수정"""
# 성 편향 대체 규칙
GENDER_NEUTRAL = {
r'\bhe or she\b': 'they',
r'\bhis or her\b': 'their',
r'\bchairman\b': 'chairperson',
r'\bpoliceman\b': 'police officer',
r'\bfireman\b': 'firefighter',
r'\bstewardess\b': 'flight attendant',
}
def process(self, text: str) -> str | None:
"""
Returns:
정제된 텍스트, 또는 None (제안 불가 판단 시)
"""
# 1) 너무 긴 제안 제거
if len(text.split()) > 8:
return None
# 2) 성 중립화
for pattern, replacement in self.GENDER_NEUTRAL.items():
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
# 3) Llama Guard로 NSFW 탐지 (실제 구현)
# if self.safety_model.is_unsafe(text):
# return None
return text
processor = PostProcessor()
print(processor.process("Please ask the chairman for his or her approval"))
# → "Please ask the chairperson for their approval"
연습문제
문제 1 (기초 — 직접 실행 가능)
과제: tiktoken으로 아래 이메일을 토큰화하고, 문자 단위 vs BPE의 어휘집 크기 차이를 출력하라.
text = "Hi Ethan! Haven't seen you for a while. Hope everything's going well."
풀이
import tiktoken
text = "Hi Ethan! Haven't seen you for a while. Hope everything's going well."
# BPE (cl100k_base)
enc = tiktoken.get_encoding("cl100k_base")
bpe_tokens = enc.encode(text)
# 문자 단위 어휘집
char_vocab = set(text)
print(f"BPE 토큰 수: {len(bpe_tokens)}") # ~16개
print(f"BPE 어휘집 크기: {enc.n_vocab:,}") # 100,277개
print(f"문자 단위 어휘집 크기: {len(char_vocab)}") # ~30개
print("\nBPE 토큰:")
for token in bpe_tokens:
print(f" {token:6d} → '{enc.decode([token])}'")
문제 2 (중급 — API 기반 빠른 프로토타입)
과제: Claude API로 스마트 편지쓰기 MVP를 구현하라. (경로 A: API 활용)
풀이
import anthropic
client = anthropic.Anthropic()
def smart_compose_api(email_partial: str, num_suggestions: int = 3) -> list[str]:
"""
Claude API를 이용한 스마트 편지쓰기 MVP
경로 A (API 활용) — 프로덕션 배포 전 빠른 검증용
"""
suggestions = []
for _ in range(num_suggestions):
response = client.messages.create(
model="claude-haiku-4-5-20251001", # 빠르고 저렴한 모델
max_tokens=20,
messages=[{
"role": "user",
"content": (
"Complete this email naturally with 3-8 words. "
"Reply with ONLY the completion, nothing else.\n\n"
f"Email so far: '{email_partial}'"
)
}]
)
suggestions.append(response.content[0].text.strip())
# 중복 제거
return list(dict.fromkeys(suggestions))
# 테스트
context = "Hi Sarah, I wanted to follow up on our meeting"
for s in smart_compose_api(context):
print(f" → {context} | {s}")
문제 3 (고급 — LoRA 파인튜닝 설계)
상황: 3B 파라미터 모델을 이메일 데이터로 LoRA 파인튜닝할 때, r 값을 8, 16, 64로 변경하면 어떤 트레이드오프가 생기는가?
풀이
`r` (랭크)은 LoRA 어댑터의 표현력을 결정한다. | r 값 | 학습 파라미터 (q+v proj, d=3072) | 메모리 | 표현력 | 권장 상황 | |------|-------------------------------|--------|--------|---------| | 8 | 2×2×3072×8 = 98,304 | 최소 | 낮음 | 간단한 스타일 조정 | | 16 | 2×2×3072×16 = 196,608 | 중간 | 중간 | **이메일 완성 추천** | | 64 | 2×2×3072×64 = 786,432 | 높음 | 높음 | 복잡한 태스크 전환 | 실무 기준: 이메일 완성처럼 원래 언어 능력 기반 작업은 `r=16`이 충분하다. `r=64`는 전체 파인튜닝 대비 메모리는 여전히 적지만 과적합 위험이 있다.# 실험 코드
for r in [8, 16, 64]:
config = LoraConfig(r=r, lora_alpha=r*2, target_modules=["q_proj", "v_proj"])
model_copy = get_peft_model(base_model, config)
trainable, total, pct = model_copy.get_nb_trainable_parameters()
print(f"r={r:2d}: 학습 파라미터 {trainable/1e6:.2f}M ({pct:.2f}%)")
부록 A: 책 개념 → 2026 도구 매핑
| 책의 개념 | 핵심 원리 | 2026 구현 방법 |
|---|---|---|
| 텍스트 정제 | PII 제거, 노이즈 제거 | presidio-analyzer |
| BPE 토큰화 | 자주 등장하는 쌍 합치기 | tiktoken, transformers.AutoTokenizer |
| 텍스트 임베딩 | 토큰 ID → 벡터 | 모델 내부 embed_tokens 레이어 |
| 위치 인코딩 | 순서 정보 주입 | RoPE (자동 처리, 신경 쓸 일 없음) |
| 사전 학습 | 일반 언어 학습 | HuggingFace에서 다운로드 |
| 미세 조정 | 이메일 특화 학습 | LoRA / peft + trl 라이브러리 |
| 빔 검색 | 상위 k 시퀀스 추적 | model.generate(num_beams=5) |
| Perplexity | 예측 불확실성 지표 | evaluate.load("perplexity") |
| ExactMatch@N | N단어 정확 일치율 | 직접 구현 (10줄) |
| 트리거링 서비스 | 언제 제안할지 결정 | FastAPI + 디바운스 |
| 구절 생성기 | 후보 생성 + 필터링 | vLLM 서빙 엔진 |
| 후처리 서비스 | 편향 제거 | 규칙 기반 + Llama Guard 3 |
부록 B: 2026년 스마트 편지쓰기 실전 스택
레이어 선택지 이유
─────────────────────────────────────────────────────
베이스 모델 Llama 3.2 3B / Gemma 3 4B 작고 빠름, Apache 2.0
파인튜닝 LoRA (r=16) + QLoRA A100 1장으로 가능
서빙 vLLM PagedAttention으로 처리량 최대화
API 서버 FastAPI + asyncio 비동기 스트리밍
안전 필터 Llama Guard 3 Meta 오픈소스
모니터링 LangSmith / Prometheus A/B 테스트, 지표 수집
인프라 Modal / Runpod / AWS p4d GPU 서버리스
부록 C: 설치 명령어 모음
# 핵심 라이브러리 한 번에 설치
pip install \
transformers \ # 모델 로드/토크나이저
peft \ # LoRA
trl \ # SFTTrainer
bitsandbytes \ # 4비트 양자화
accelerate \ # 멀티 GPU
datasets \ # 데이터셋
evaluate \ # 평가 지표
tiktoken \ # OpenAI BPE
vllm \ # 프로덕션 서빙
fastapi uvicorn \ # API 서버
presidio-analyzer # PII 탐지
# vLLM 서버 시작
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.2-3B \
--enable-lora \
--lora-modules email=./email-compose-lora \
--port 8000
클릭하거나 Space를 눌러 뒤집기